/* eslint-disable @typescript-eslint/no-explicit-any */
import { Parameters } from "./parameters";

interface URIObject {
  scheme: string;
  user: string | undefined;
  host: string;
  port: number | undefined;
}

/**
 * URI.
 * @public
 */
export class URI extends Parameters {
  public headers: {[name: string]: Array<string>} = {};
  private normal: URIObject;
  private raw: URIObject;

  /**
   * Constructor
   * @param scheme -
   * @param user -
   * @param host -
   * @param port -
   * @param parameters -
   * @param headers -
   */
  constructor(
    scheme = "sip",
    user: string,
    host: string,
    port?: number,
    parameters?: { [name: string]: string | number | null },
    headers?: {[name: string]: Array<string>}
  ) {
    super(parameters || {});
    // Checks
    if (!host) {
      throw new TypeError('missing or invalid "host" parameter');
    }

    for (const header in headers) {
      // eslint-disable-next-line no-prototype-builtins
      if (headers.hasOwnProperty(header)) {
        this.setHeader(header, headers[header]);
      }
    }

    // Raw URI
    this.raw = {
      scheme,
      user,
      host,
      port
    };

    // Normalized URI
    this.normal = {
      scheme: scheme.toLowerCase(),
      user,
      host: host.toLowerCase(),
      port
    };
  }

  get scheme(): string { return this.normal.scheme; }
  set scheme(value: string) {
    this.raw.scheme = value;
    this.normal.scheme = value.toLowerCase();
  }

  get user(): string | undefined { return this.normal.user; }
  set user(value: string | undefined) {
    this.normal.user = this.raw.user = value;
  }

  get host(): string { return this.normal.host; }
  set host(value: string) {
    this.raw.host = value;
    this.normal.host = value.toLowerCase();
  }

  get aor(): string { return this.normal.user + "@" + this.normal.host; }

  get port(): number | undefined { return this.normal.port; }
  set port(value: number | undefined) {
    this.normal.port = this.raw.port = value === 0 ? value : value;
  }

  public setHeader(name: string, value: Array<string> | string): void {
    this.headers[this.headerize(name)] = (value instanceof Array) ? value : [value];
  }

  public getHeader(name: string): Array<string> | undefined {
    if (name) {
      return this.headers[this.headerize(name)];
    }
  }

  public hasHeader(name: string): boolean {
    // eslint-disable-next-line no-prototype-builtins
    return !!name && !!this.headers.hasOwnProperty(this.headerize(name));
  }

  public deleteHeader(header: string): Array<string> | undefined {
    header = this.headerize(header);

    // eslint-disable-next-line no-prototype-builtins
    if (this.headers.hasOwnProperty(header)) {
      const value = this.headers[header];
      delete this.headers[header];
      return value;
    }
  }

  public clearHeaders(): void {
    this.headers = {};
  }

  public clone(): URI {
    return new URI(
      this._raw.scheme,
      this._raw.user || "",
      this._raw.host,
      this._raw.port,
      JSON.parse(JSON.stringify(this.parameters)),
      JSON.parse(JSON.stringify(this.headers)));
  }

  public toRaw(): string {
    return this._toString(this._raw);
  }

  public toString(): string {
    return this._toString(this._normal);
  }

  private get _normal(): URIObject { return this.normal; }

  private get _raw(): URIObject { return this.raw; }

  private _toString(uri: any): string {
    let uriString: string  = uri.scheme + ":";
    // add slashes if it's not a sip(s) URI
    if (!uri.scheme.toLowerCase().match("^sips?$")) {
      uriString += "//";
    }
    if (uri.user) {
      uriString += this.escapeUser(uri.user) + "@";
    }
    uriString += uri.host;
    if (uri.port || uri.port === 0) {
      uriString += ":" + uri.port;
    }

    for (const parameter in this.parameters) {
      // eslint-disable-next-line no-prototype-builtins
      if (this.parameters.hasOwnProperty(parameter)) {
        uriString += ";" + parameter;

        if (this.parameters[parameter] !== null) {
          uriString += "=" + this.parameters[parameter];
        }
      }
    }

    const headers: Array<string> = [];
    for (const header in this.headers) {
      // eslint-disable-next-line no-prototype-builtins
      if (this.headers.hasOwnProperty(header)) {
        // eslint-disable-next-line @typescript-eslint/no-for-in-array
        for (const idx in this.headers[header]) {
          // eslint-disable-next-line no-prototype-builtins
          if (this.headers[header].hasOwnProperty(idx)) {
            headers.push(header + "=" + this.headers[header][idx]);
          }
        }
      }
    }

    if (headers.length > 0) {
      uriString += "?" + headers.join("&");
    }

    return uriString;
  }

  /*
   * Hex-escape a SIP URI user.
   * @private
   * @param {String} user
   */
  private escapeUser(user: string): string {
    let decodedUser: string;

    // FIXME: This is called by toString above which should never throw, but
    // decodeURIComponent can throw and I've seen one case in production where
    // it did throw resulting in a cascading failure. This class should be
    // fixed so that decodeURIComponent is not called at this point (in toString).
    // The user should be decoded when the URI is constructor or some other
    // place where we can catch the error before the URI is created or somesuch.
    // eslint-disable-next-line no-useless-catch
    try {
      decodedUser = decodeURIComponent(user);
    } catch (error) {
      throw error;
    }

    // Don't hex-escape ':' (%3A), '+' (%2B), '?' (%3F"), '/' (%2F).
    return encodeURIComponent(decodedUser)
      .replace(/%3A/ig, ":")
      .replace(/%2B/ig, "+")
      .replace(/%3F/ig, "?")
      .replace(/%2F/ig, "/");
  }

  private headerize(str: string): string {
    const exceptions: any = {
      "Call-Id": "Call-ID",
      "Cseq": "CSeq",
      "Min-Se": "Min-SE",
      "Rack": "RAck",
      "Rseq": "RSeq",
      "Www-Authenticate": "WWW-Authenticate",
    };
    const name: Array<string> = str.toLowerCase().replace(/_/g, "-").split("-");
    const parts: number = name.length;
    let hname = "";

    for (let part = 0; part < parts; part++) {
      if (part !== 0) {
        hname += "-";
      }
      hname += name[part].charAt(0).toUpperCase() + name[part].substring(1);
    }
    if (exceptions[hname]) {
      hname = exceptions[hname];
    }
    return hname;
  }
}

/**
 * Returns true if URIs are equivalent per RFC 3261 Section 19.1.4.
 * @param a URI to compare
 * @param b URI to compare
 *
 * @remarks
 * 19.1.4 URI Comparison
 * Some operations in this specification require determining whether two
 * SIP or SIPS URIs are equivalent.
 *
 * https://tools.ietf.org/html/rfc3261#section-19.1.4
 * @internal
 */
export function equivalentURI(a: URI, b: URI): boolean {

  // o  A SIP and SIPS URI are never equivalent.
  if (a.scheme !== b.scheme) {
    return false;
  }

  // o  Comparison of the userinfo of SIP and SIPS URIs is case-
  //    sensitive.  This includes userinfo containing passwords or
  //    formatted as telephone-subscribers.  Comparison of all other
  //    components of the URI is case-insensitive unless explicitly
  //    defined otherwise.
  //
  // o  The ordering of parameters and header fields is not significant
  //    in comparing SIP and SIPS URIs.
  //
  // o  Characters other than those in the "reserved" set (see RFC 2396
  //    [5]) are equivalent to their ""%" HEX HEX" encoding.
  //
  // o  An IP address that is the result of a DNS lookup of a host name
  //    does not match that host name.
  //
  // o  For two URIs to be equal, the user, password, host, and port
  //    components must match.
  //
  // A URI omitting the user component will not match a URI that
  // includes one.  A URI omitting the password component will not
  // match a URI that includes one.
  //
  // A URI omitting any component with a default value will not
  // match a URI explicitly containing that component with its
  // default value.  For instance, a URI omitting the optional port
  // component will not match a URI explicitly declaring port 5060.
  // The same is true for the transport-parameter, ttl-parameter,
  // user-parameter, and method components.
  //
  // Defining sip:user@host to not be equivalent to
  // sip:user@host:5060 is a change from RFC 2543.  When deriving
  // addresses from URIs, equivalent addresses are expected from
  // equivalent URIs.  The URI sip:user@host:5060 will always
  // resolve to port 5060.  The URI sip:user@host may resolve to
  // other ports through the DNS SRV mechanisms detailed in [4].

  // FIXME: TODO:
  // - character compared to hex encoding is not handled
  // - password does not exist on URI currently
  if (a.user !== b.user || a.host !== b.host || a.port !== b.port) {
    return false;
  }

  // o  URI uri-parameter components are compared as follows:
  function compareParameters(a: URI, b: URI): boolean {
    //  -  Any uri-parameter appearing in both URIs must match.
    const parameterKeysA = Object.keys(a.parameters);
    const parameterKeysB = Object.keys(b.parameters);
    const intersection = parameterKeysA.filter(x => parameterKeysB.includes(x));
    if (!intersection.every(key => a.parameters[key] === b.parameters[key])) {
      return false;
    }

    //  -  A user, ttl, or method uri-parameter appearing in only one
    //     URI never matches, even if it contains the default value.
    if (!["user", "ttl", "method", "transport"].every(key => a.hasParam(key) && b.hasParam(key) || !a.hasParam(key) && !b.hasParam(key))) {
      return false;
    }

    //  -  A URI that includes an maddr parameter will not match a URI
    //     that contains no maddr parameter.
    if (!["maddr"].every(key => a.hasParam(key) && b.hasParam(key) || !a.hasParam(key) && !b.hasParam(key))) {
      return false;
    }

    //  -  All other uri-parameters appearing in only one URI are
    //     ignored when comparing the URIs.
    return true;
  }

  if (!compareParameters(a, b)) {
    return false;
  }

  // o  URI header components are never ignored.  Any present header
  //    component MUST be present in both URIs and match for the URIs
  //    to match.  The matching rules are defined for each header field
  //    in Section 20.
  const headerKeysA = Object.keys(a.headers);
  const headerKeysB = Object.keys(b.headers);

  // No need to check if no headers
  if (headerKeysA.length !== 0 || headerKeysB.length !== 0) {

    // Must have same number of headers
    if (headerKeysA.length !== headerKeysB.length) {
      return false;
    }

    // Must have same headers
    const intersection = headerKeysA.filter(x => headerKeysB.includes(x));
    if (intersection.length !== headerKeysB.length) {
      return false;
    }

    // FIXME: Not to spec. But perhaps not worth fixing?
    // Must have same header values
    // It seems too much to consider multiple headers with same name.
    // It seems too much to compare two header params according to the rule of each header.
    // We'll assume a single header and compare them string to string...
    if (!intersection.every(key => a.headers[key].length && b.headers[key].length && a.headers[key][0] === b.headers[key][0])) {
      return false;
    }
  }

  return true;
}